Skip to content

Introduce deprecated coroutine builder overloads accepting a Job#4435

Merged
dkhalanskyjb merged 6 commits intodevelopfrom
dkhalanskyjb/launch-async-no-job
Mar 30, 2026
Merged

Introduce deprecated coroutine builder overloads accepting a Job#4435
dkhalanskyjb merged 6 commits intodevelopfrom
dkhalanskyjb/launch-async-no-job

Conversation

@dkhalanskyjb
Copy link
Copy Markdown
Collaborator

This is a small step towards deprecating the practice of breaking
structured concurrency by passing a Job to a coroutine builder.

Thanks to @LouisCAD for the idea!

See #3670

@dkhalanskyjb dkhalanskyjb requested a review from qwwdfsad May 13, 2025 13:28
@LouisCAD
Copy link
Copy Markdown
Contributor

Awesome stuff!

Comment thread kotlinx-coroutines-core/common/src/Guidance.kt Outdated
* throw IllegalStateException("$it is tired of all this")
* }
* }
* coroutines.joinAll()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do an explicit join here? supervisorScope should wait for its children, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it will. The idea was to highlight how joinAll will not fail in this scenario, but this piece of documentation may not be a good place for that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was also confusing to me. A simple comment on this line // joining failed supervised coroutines doesn't fail either would help!

Comment thread kotlinx-coroutines-core/common/src/Guidance.kt Outdated
Comment thread kotlinx-coroutines-core/common/src/Guidance.kt Outdated
Comment on lines +223 to +229
* scope.async(start = CoroutineStart.ATOMIC) {
* withContext(NonCancellable) {
* // this line will be reached even if the parent is cancelled
* }
* // note: the cancellation exception *can* be thrown here,
* // losing the computed value!
* }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean you can loose the value, even if there is no code after the withContext block?

scope.async(start = ATOMIC) {
    withContext(NonCancellable) {
        dontWantToLooseThisResource()
    }
    // no code here, or only code without suspension points
}

The documentation for withContext says there will be no cancellation on entry/exit with NonCancellable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comment is misleading, but yes, the value can be lost, just not because of withContext. The problem is that if the Deferred is cancelled before returning a value, it will no longer be able to finish with a value, even if the computation succeeds.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that's a bit surprising to me. I guess it's because it could have children that haven't completed yet?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, even without any children: https://pl.kotl.in/-ZYf_PR9z
The reason can be seen in the diagram in https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/ : once a Job gets cancelled, there is no way back to being Active or Completing or returning a value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, good to know 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd make sense to make an overload to specifically accepts NonCancellable. It's doable since NonCancellable is not just a Job instance, but an object that has its own exclusive type.

That overload could then have the KDoc just for this use case, and could also have a matching ReplaceWith clause.

* } finally {
* // if `await` fails because the current job is cancelled,
* // also cancel the now-unnecessary computations
* deferred.cancel()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be pointed out that just calling cancel on deferred won't prevent the current coroutine from completing before the now cancelled coroutine in the other scope completes?

@LouisCAD
Copy link
Copy Markdown
Contributor

LouisCAD commented May 19, 2025

With these changes, someScope.launch(NonCancellable) { ... } will no longer compile. I've yet to see a tangible use case for that, where the someScope is useful (for anything but Job, that'd be), but wanted to point it out.

Another construct could be thought out, or an overload taking the NonCancellable type could be introduced, if that actually needs to be addressed.

@dkhalanskyjb
Copy link
Copy Markdown
Collaborator Author

@LouisCAD, what is the compilation error? I've just checked, and launch(NonCancellable) compiles for me (with a warning, as intended). I consider launch(NonCancellable) to be incorrect, with launch(start = CoroutineStart.ATOMIC) { withContext(NonCancellable) { /* computation */ } } being a strictly better version of the same.

@LouisCAD
Copy link
Copy Markdown
Contributor

Right, it compiles with just a warning.
For some reason, I thought the deprecation level was error, though I guess that is where it's headed, fast forward into the future.

Came back here to comment about a specific use case I just had.

In the snippet below, I want an async that contains its crashes to await()/join() calls on the resulting Deferred, and doesn't bubble it up to its parents.
I can't use supervisorScope { … } this time, so I'm using this:

async(SupervisorJob(coroutineContext.job)) { // this (the receiver) is a `CoroutineScope`
    // Some throwing code
}

With those changes, it'd trigger a warning, even though it's technically correct.

Addressing #4329 would provide a good alternative, though.

/**
* Deprecated version of [future] that accepts a [Job].
*
* See the documentation for the non-deprecated [future] function to learn about the functionality of this function.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to fully-qualify the non-deprecated [future], so that it's clickable? Currently, it stays on the same page if I click on it in the kdoc in the IDE.

And the deprecated [async] below shows the non-deprecated async kdoc, confusingly.

I noticed that we don't really care about navigability of kdocs. If so, is it because of some technical difficulties outside of kx.coroutines control?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't any way to affect the resolution here, unfortunately. Not only do we not have control over this, but also, the resolution results may be different between the IDE and the rendered html documents.

In such cases, there's an argument to be made for not leaving a link at all. However, my preferred design for the improved KDoc navigability includes warnings for ambiguous cases. With that in mind, I'm hopeful that one day, after we upgrade to a new Kotlin version, we'll see a hundred "ambiguous reference in KDoc" warnings and will be able to easily audit and fix them.

*
* It is incorrect to pass a [Job] as [context] to [produce], [async], or [launch],
* because this violates structured concurrency.
* The passed [Job] becomes the sole parent of the newly created coroutine, which completely severs the tie between
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"completely severs the tie" -> "removes the parent-child relationship"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The target audience for this is people who don't have a solid grasp on structured concurrency yet. To them, "parent-child relationship" may be a phrase devoid of special meaning. The dramatic "severs the tie" phrasing is intended to clearly describe the stakes: one piece of code is completely unaware of the other. The more experienced users can be expected to understand the specifics from the "sole parent" remark.

* - The [CoroutineScope] can only complete when all its children complete.
* If the [CoroutineScope] is lexically scoped (for example, created by [coroutineScope], [supervisorScope],
* or [withContext]), this means that
* the lexical scope will only be exited (and the calling function will finish) once all child coroutines complete.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Points 1-3 are explanation to why "cancellation of the parent job cancels the children" is useful, and point 4 is an independent one. Consider moving points 1-3 into sub-points.

Then your structure will be: structured concurrency ensures

  • A cancelled parents cancel children (A is important because 1, 2, 3)
  • B parent waits for children

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See a note on lifecycle below, related to this section.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two notes are the only non-trivial comments, requesting changes in this section (and other sections referring to this one.)

This section establishes the ground rules, and the following sections should refer to arguments listed here.

The whole narrative is like this:

  • Passing a Job explicitly prevents structured concurrency, don't do it, we are about to give you a collection of samples to use instead.
  • Defining structured concurrency (parent-child relationship), various implications of parent-child relationship, and what scenarios they prevent or enable.
  • List of samples, each sample demonstrates how it preserves structured concurrency.

Currently, the structured concurrency section defines things more loosely and doesn't precisely match with the arguments in the samples.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more robust separation would be 1,2,3-cancellation, 4-completion, because in 3, the cancellation also goes the other way, from the child to the parent. I don't understand the benefits of such a grouping, though.

In response to your proposal, I did change the structure a bit to flow more naturally, though I'm not sure that it aligns with what you had in mind.

*
* Sometimes, it is undesirable for the child coroutine to react to the cancellation of the parent: for example,
* some computations have to be performed either way.
* `produce(NonCancellable)` or `produce(Job())` can be used to achieve this effect.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear if this is what you should do, or what you should avoid.

* some computations have to be performed either way.
* `produce(NonCancellable)` or `produce(Job())` can be used to achieve this effect.
*
* Alternative approaches that preserve structured concurrency are this:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Alternative approaches that preserve structured concurrency are this:
* Here's an alternative approach that preserves structured concurrency:

* // are only available through `channel`
* }
* }
* // this line will only be reached when the `produce` coroutine complete
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

completes

* // this line will be reached when both `launch` and `produce` complete
* ```
*
* All of these approaches preserve the ability of a parent to cancel the children and to wait for their completion.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this very succinct summary of why we need to strive to preserve structured concurrency.

*
* ```
* GlobalScope.produce {
* // this is explicitly a rogue computation
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the term rogue. It's emotionally charged and doesn't hint at what it means. Possibly, an orphaned coroutine would work better? Did we leak this to outside documentation already, or is there a room for negotiation?

In this particular case no need to decide now, we could just say: this computation doesn't have a parent, and thus cannot be cancelled.

* Alternative approaches that preserve structured concurrency are this:
*
* ```
* // Guarantees the completion
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit verbose given L550?
If you decide to keep, then also add it in launch.

* throw IllegalStateException("$it is tired of all this")
* }
* }
* coroutines.joinAll()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was also confusing to me. A simple comment on this line // joining failed supervised coroutines doesn't fail either would help!

dkhalanskyjb and others added 6 commits March 26, 2026 10:37
This is a small step towards deprecating the practice of breaking
structured concurrency by passing a `Job` to a coroutine builder.

See #3670
Co-authored-by: Luca Kellermann <lukellmann@gmail.com>
@dkhalanskyjb dkhalanskyjb force-pushed the dkhalanskyjb/launch-async-no-job branch from 1357b31 to cd1e3b6 Compare March 26, 2026 10:27
@dkhalanskyjb dkhalanskyjb merged commit 378e3a4 into develop Mar 30, 2026
1 check failed
@dkhalanskyjb dkhalanskyjb deleted the dkhalanskyjb/launch-async-no-job branch March 30, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants